v1.11 Risk & Reliability: one outage model, three sources - #17
Merged
Conversation
Approved brainstorm covering backlog items B (hazards + mitigation), C (dynamic Grid) and D (Overclock rework). The seven owner decisions are recorded as locked, with the reasoning, so planning does not relitigate them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the approved design into an executable plan. Task order front-loads the risk exactly as spec §13 asks: the config boolean type, then the pure integral, then evaluation wiring that proves zero outages changes nothing, then each source in turn, with the breaking Overclock rework deliberately late and the goals suite as its net. Two things worth flagging for whoever executes it: - The Overclock conversion is defined as a RATIO of the Racks lane (1 + gain * ocOutput/racksOutput). At the default gain of 1 that is algebraically racksOutput + ocOutput, so goalCtx.totalOutputPerSec is unchanged on the deploy and the existing goals/contracts/achievements suites pass untouched - which is what de-risks "the highest-risk edit in the release". The balance pass becomes one tunable. - effectiveFactor takes (outages, lane, index, from, to) rather than the spec's (outages, scope, from, to). Stated as a deviation in the plan; every call site is a loop over a lane's indices and the sketched signature would allocate a throwaway scope object per tier per evaluation. Semantics identical. Every signature in the plan was re-verified against this branch first (req.user.sub, the `tiers` lane name, scheduleAnomaly's shape), and the integral, the derivation hash, the conversion algebra and the cure's price floor were all checked numerically before the plan was committed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
validateConfig accepted only numbers, so an admin toggle had nowhere to live. TUNABLES descriptors now take type: 'boolean' (existing rows are numeric by default, so nothing else changes), validateConfig enforces the type in both directions, and upgradeConfig copies booleans through. Event modifiers stay numeric-only: mergeEventModifiers would happily setAtPath a number onto a boolean path and the merged doc would then fail validateConfig with a confusing error, so validateModifiers rejects boolean paths up front and AdminEvents drops them from the picker (whose min/max validation would read undefined on them regardless). An event can turn the risk system up; it cannot flip its switches. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One object covers all three sources. The integral has a closed form because within an evaluation window there are no player actions, so the only thing that varies is which outages are active and each is a constant factor over an interval - collect the boundaries, multiply the covering factors per sub-interval, weight by length. Exact, and evaluate() stays one multiplication per lane rather than stepping the simulation. Cold Storage is excluded structurally, in OUTAGE_LANES, rather than by each caller remembering to skip it - a wildcard scope means every ACTIVE lane. The brute-force cross-check in the test file is deliberate: it is the thing that would catch a future 'simplification' into sampling. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Wires the integral into both branches with nothing yet creating an outage, so the interesting assertion is the negative one: with zero outages, production is bit-identical to today and every pre-existing evaluate test passes untouched. The offline branch carries the decision-5 comment at the call site. The factor is computed over the WHOLE absence and applied to the CAPPED payout, so an incident covering 2 of 12 absent hours costs 2/12ths of what was credited. That reads wrong until you see why the literal first-N-hours reading was rejected: at one incident per six hours most would land in unpaid time and cost nothing, gutting the system for the players it should reach most. Pruning happens after the integral, not before - an outage that ended mid-window still degraded the part it covered. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…them in evaluate() The client runs evaluate() optimistically against this same code, so a Math.random() at evaluation time would make the two sides disagree about what happened during an absence. Identity, target and duration are therefore derived from the scheduled timestamp via a pure hash - only WHEN the next one happens uses an injected rng, which is safe precisely because that time is never displayed and is overwritten by the authoritative state on the next reconcile. Two easy-to-get-wrong details are load-bearing: the next hazard is scheduled from the FIRE time (scheduling from the evaluation's "now" would make a long absence produce exactly one hazard however long it was), and MAX_HAZARDS_PER_EVALUATION bounds the loop so a save with a 1970 nextHazardAt reschedules instead of spinning. Deviation from the plan: absorbWithSupply is implemented here rather than landing as a stub in Task 4 and being replaced in Task 5. It is inert until meta.supplies exists (the !bag guard), so Task 4's tests are unaffected and the seam never needs rewriting. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…d notice Supplies are bought with credits but stored in meta. That split is the point: credits are the run currency, so this is a sink for what players have most of, and meta survives Migrate, which gives a genuine reason to spend down before prestiging rather than watching the balance evaporate. Absorption happens at fire time inside evaluate(), so it reaches a hazard that starts and ends during a nine-hour absence - the only defence that can. It is also the one place in the release that decrements a stored value, which is not a decision-1 violation: a supply is a consumable bought for exactly this purpose. The decision-1 property test in Task 9 excludes meta.supplies by name for that reason. Prices are seconds of current output rather than flat, matching social.contractFlopsSeconds and batchQueue.blockFlopsSeconds - a flat price is a meaningful sink for an hour and free forever after. The rate is read from goalCtx, which is deliberately outage-free: pricing off a degraded rate would make supplies cheapest exactly when an incident is running. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Coming back to a running incident should never leave you a spectator, but it must never be the cheap path either. The formula puts the cure's floor at cureMultiplier times the supply that would have prevented it, and the test asserts that as a property across every kind, output rate and elapsed fraction rather than at one sample point - if the formula ever changes, that sweep is the contract it has to satisfy. Curing truncates endAt to now rather than splicing the outage out, so an evaluation window straddling the cure still integrates the time the lane was genuinely down. pruneExpired removes it on the next pass. Maintenance and overheats are not curable: one is telegraphed rather than misfortune, the other is the player's own doing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Scheduled on the server load path rather than inside evaluate(), following the scheduleAnomaly precedent but for a sharper reason: this window is DISPLAYED with a countdown, so if the client drew it from its own rng the countdown would jump on every reconcile. evaluate() only activates an already-scheduled window, which needs no derivation and is deterministic on both sides. Fixes a bug the test caught while being written. activateDueMaintenance originally skipped a window whose endAt had already passed, reasoning it was "missed entirely" - but a window covering the whole evaluation gap ends exactly at now, so that guard paid the player in full for time they were demonstrably down. The guard cannot see lastEvaluatedAt, so it cannot make that call correctly; the integral can, and already ignores anything ending before the window starts. Removed it and left the reasoning in a comment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… a rack tier The lane's output is now expressed as a fraction of the Racks lane's: boost = 1 + gain * overclockOutput / racksOutput. At the default gain of 1 that is algebraically racks + overclock, so goalCtx.totalOutputPerSec is unchanged across the deploy. That is what makes this survivable: contracts, achievements, streak and reducer.economy needed zero edits, and the balance pass is one tunable rather than a re-costing exercise. One goals test did change, and its premise is exactly what the rework removes: it asserted that overclock nodes with NO racks produce output. They no longer do - there is nothing to amplify. Rewritten to assert the new contract, plus a companion test pinning the nothing-to-amplify case. The boost is deliberately not degraded by outages. Ransomware's wildcard scope already covers the Racks lane the boost multiplies, so applying it to both would square the penalty. Overheating now downs one rack tier, its victim derived from the timestamp so two clients reconciling the same overheat agree. legacyFreeze is deliberately NOT gated on the toggle: overheatOutage falls back to the old cooldown when there is no owned rack to down, and a cooldown that is set but not honoured would let heat re-cross the cap on every evaluation. Gating it that way also keeps the condition identical to goalCtx's, so the displayed rate and the produced rate cannot disagree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…as a property This is a live game with real players, so risk.enabled has to be a true kill switch rather than a pause. Clearing happens before the integral, which means even the window being evaluated right now is paid in full - a player mid-ransomware when the owner flips the switch is visibly un-broken on their next reconcile rather than left throttled by a system that no longer exists. Decision 1 is now enforced as a property across a 60-seed randomised sweep rather than by inspection: no hazard may reduce credits, wafers, tapes, lifetime output or any owned count. meta.supplies is excluded by design and by name - it is the one consumable bought expressly to be spent. That test is the guardrail that stops a later small change reintroducing asset loss. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…notice toasts The strip reads server.outages directly, which is why it can tell one coherent story about a slowdown rather than reconciling two - there is only ever one list. It also carries the upcoming maintenance window, the single thing in this release the player is allowed to see coming. A new Resilience tab gives supplies, the standing risk rate and any running incident one home. The rate is derived from config and the next hazard time is never rendered anywhere: showing it would collapse the prepaid economy into buying one licence twenty minutes beforehand. The Racks panel now labels a downed tier with its reason. A tier silently producing nothing reads as a bug, and after this release that will happen routinely. Notices reuse the existing toast rather than adding a notification system, following the v1.10 rule that rewards use the modal and everything else uses the toast. The absorbed notice is the load-bearing one. Per the standing tour obligation, the two new steps are appended to onboarding.js rather than registered as their own tour, so onboarding stays a strict superset. tests/tours.test.js hardcodes step counts; both moved by exactly 2, which is the check that the steps are ungated - a fresh save can be hit by a hazard, so it must be told how to prepare. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nine end-to-end checks over a real server: an outage costing output, Cold Storage provably untouched beside it, buying and refusing a supply, the firing bound terminating on a 1970 nextHazardAt, offline absorption, the kill switch clearing a live incident, the boolean tunable type rejected end to end, and the Resilience tab rendering in a browser. Two pre-existing smoke checks asserted behaviour this release deliberately changes, and were updated rather than worked around: - smoke-v12's overheat check expected heatCooldownUntil and the frozen-lane messaging. The penalty moved to the Racks lane, so it now asserts the overheat outage and that the outage strip names it. Asserted on the strip rather than the Racks panel because the strip is in the sticky header and is visible whatever tab is open and whichever tier was picked - including one past unlockedUpTo, which is what made the first attempt flaky. - smoke-v16 hardcoded the onboarding step count, 11 -> 13 for the two ungated Resilience steps. Verified: SQLite 793 passing, Postgres 819 passing, 66 smoke checks across every suite, client builds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
NeverEndingCode
marked this pull request as ready for review
August 9, 2026 03:11
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements
docs/superpowers/specs/2026-08-08-v1.11-risk-reliability-design.mdviadocs/superpowers/plans/2026-08-08-v1.11-risk-reliability.md. Eleven tasks, one commit each; execution log in...-notes.md.What this is
Hazards, scheduled Grid maintenance and the reworked overheat penalty are not three systems — they are one system with three sources. Everything is an outage:
{ id, kind, scope, factor, startAt, endAt, source }instate.server.outages. That single representation is why the UI can tell one coherent story about a slowdown instead of reconciling two lists.The two things worth reviewing closely
1. The integral (
shared/outages.js). Within an evaluation window there are no player actions, so production is piecewise-constant and has a closed form.evaluate()stays one multiplication per lane — no sub-stepping. It is exact, andtests/outages.test.jscross-checks it against a brute-force integral specifically so a future "simplification" into sampling fails loudly.2. The Overclock conversion (
shared/gameRules.js). The lane stops producing and becomesboost = 1 + gain * ocOutput / racksOutput. At the shipped gain of 1 that is algebraicallyracks + oc, sogoalCtx.totalOutputPerSecis unchanged on deploy. This is what made the spec's "highest-risk edit" survivable:contracts,achievements,streakandreducer.economyneeded zero edits. Only one goals test changed, and only because its premise — that Overclock nodes with no racks produce output — is exactly what the rework removes.If a future change makes those four suites move, the conversion has drifted from output-neutral. Check
overclockBoost()before touching a test.Deliberate calls a reviewer should push back on if they disagree
effectiveFactor(outages, lane, index, from, to)rather than the spec §4 sketch's(outages, scope, …)— every call site loops over a lane's indices.{lane:'*'}already covers the Racks lane it multiplies, so applying it to both would square the penalty.goalCtxdeliberately ignores outages — it reports installed capacity, so a hazard cannot make a contract easier or a supply cheaper.meta.suppliesis the one stored value that decreases. The decision-1 property test excludes it by name.Two bugs the tests caught during implementation
activateDueMaintenanceskipped a window whoseendAthad passed as "missed" — but a window covering the whole evaluation gap ends exactly atnow, so that paid the player in full for time they were down.legacyFreezewas gated on the shutdown toggle, butoverheatOutagefalls back to settingheatCooldownUntilwhen there is no owned rack to down — so the cooldown was set and never honoured, and heat re-crossed the cap every evaluation.Verification
TEST_BACKEND=sqlite vitest run)npm run test:all)npm run smoke(every suite)cd client && npm run buildTwo pre-existing smoke checks asserted behaviour this release deliberately changes and were updated rather than worked around (
smoke-v12overheat lockout,smoke-v16onboarding step count).Not done here
Merge, then tag
main(never the branch) asv1.11.0and push the tag — the tag push is what triggers the GHCR publish.🤖 Generated with Claude Code